Skip to content

Add support for speculative contacts#2366

Draft
nvtw wants to merge 89 commits intonewton-physics:mainfrom
nvtw:dev/tw2/speculative_contacts
Draft

Add support for speculative contacts#2366
nvtw wants to merge 89 commits intonewton-physics:mainfrom
nvtw:dev/tw2/speculative_contacts

Conversation

@nvtw
Copy link
Copy Markdown
Member

@nvtw nvtw commented Apr 8, 2026

Description

Add support for speculative contacts. They are the easiest way to get a decent amount of continuous collision detection for fast moving objects while keeping the complexity low.

Checklist

  • New or existing tests cover these changes
  • The documentation is up to date with these changes
  • CHANGELOG.md has been updated (if user-facing change)

Test plan

New feature / API change

import newton

# Code that demonstrates the new capability

Summary by CodeRabbit

  • New Features

    • Speculative contact detection to catch fast-moving objects (configurable extension and update interval); new top-level SpeculativeContactConfig export
    • Contact matching for frame-to-frame rigid contact consistency with three matching modes; Model.collide accepts per-call dt to override update interval
  • Bug Fixes

    • Per-frame contact report buffers are reliably cleared on reset
  • Documentation

    • Guides and API docs for speculative contacts and contact matching
  • Tests

    • New tests covering speculative contacts and tunneling scenarios (primitive and mesh)

@nvtw nvtw self-assigned this Apr 8, 2026
@nvtw nvtw marked this pull request as draft April 8, 2026 09:48
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 8, 2026

📝 Walkthrough

Walkthrough

Adds speculative contact detection and frame-to-frame rigid contact matching to the collision pipeline, exposes a public SpeculativeContactConfig, wires speculative parameters through broad- and narrow-phase kernels, updates API signatures, adds tests, and documents the features.

Changes

Cohort / File(s) Summary
Public API Exports
newton/__init__.py, newton/_src/sim/__init__.py, docs/api/newton.rst
Exported SpeculativeContactConfig at package and sim module level; included in API docs.
Core Speculative Configuration & Pipeline
newton/_src/sim/collide.py
Added SpeculativeContactConfig; extended CollisionPipeline.__init__ and .collide() with speculative and contact-matching parameters; computes per-shape velocity/angular bounds, chooses speculative-aware AABB kernels, expands AABBs and increases contact gaps when enabled; integrates ContactMatcher and contact-matching modes (disabled/latest/sticky).
Model API
newton/_src/sim/model.py
Added keyword-only dt to Model.collide() and forwards it to CollisionPipeline.collide() to override configured collision update interval.
Contacts Reporting
newton/_src/sim/contacts.py
Contacts.clear() now also resets per-frame new/broken contact report counters and indices when allocated.
Speculative Narrow-Phase Support
newton/_src/geometry/narrow_phase.py
Threaded speculative: bool compile-time flag into primitive/GJK/MPR/mesh-triangle overlap and reducer kernel factories; kernels accept per-shape velocity and speculative tuning params and apply speculative gap extensions and directed approach projections.
Mesh-Triangle Reduction Kernel Factory
newton/_src/geometry/contact_reduction_global.py
Replaced fixed exported kernel with create_mesh_triangle_contacts_to_reducer_kernel(speculative: bool) factory; new kernel signature includes shape_lin_vel, shape_ang_speed_bound, speculative_dt, max_speculative_extension.
Mesh-Mesh SDF Narrow-Phase
newton/_src/geometry/sdf_contact.py
Added _query_mesh_face_normal() helper; added speculative compile-time mode to create_narrow_phase_process_mesh_mesh_contacts_kernel() and threaded speculative velocity/gap params and optional normal override logic.
Tests
newton/tests/test_speculative_contacts.py
New comprehensive test module exercising speculative contact generation, dt override, angular contributions, clamp behavior, and multi-frame tunneling regressions (CPU/CUDA and CUDA-only heavy scenarios).
Docs & Changelog
docs/concepts/collisions.rst, CHANGELOG.md
Added Speculative Contacts and Contact Matching documentation and a changelog entry describing the new feature.

Sequence Diagram(s)

sequenceDiagram
    participant Model
    participant CollisionPipeline
    participant Broadphase as AABB Kernels
    participant NarrowPhase as Narrow-phase Kernels
    participant ContactMatcher
    participant Contacts

    Model->>CollisionPipeline: collide(state, dt?, speculative_config?)
    CollisionPipeline->>Broadphase: run broad-phase (select speculative/non-speculative AABB)
    Broadphase-->>CollisionPipeline: candidate pairs + per-shape AABBs
    CollisionPipeline->>NarrowPhase: launch narrow-phase (speculative flags, per-shape vel/ang bounds)
    NarrowPhase-->>CollisionPipeline: raw contacts (with match keys)
    alt contact matching enabled
        CollisionPipeline->>ContactMatcher: match current vs previous sorted contacts
        ContactMatcher-->>CollisionPipeline: match indices / new/broken lists
        CollisionPipeline->>Contacts: write matched/filtered contacts + report buffers
    else contact matching disabled
        CollisionPipeline->>Contacts: write contacts
    end
    CollisionPipeline-->>Model: return Contacts
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • PR #2446: Overlaps collision pipeline contact-matching integration and per-frame sorted contact state tracking.
  • PR #2393: Modifies collision pipeline internals and Contacts handling, touching similar code paths.
  • PR #1445: Changes CollisionPipeline/Model collide signatures and related pipeline behavior.

Suggested reviewers

  • eric-heiden
  • adenzler-nvidia
  • mmacklin
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and concisely summarizes the main change: adding support for speculative contacts to the physics engine. It matches the substantial feature addition across multiple files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 8, 2026

Codecov Report

❌ Patch coverage is 93.33333% with 5 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
newton/_src/sim/contacts.py 50.00% 4 Missing ⚠️
newton/_src/sim/collide.py 97.50% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
newton/tests/test_speculative_contacts.py (1)

138-165: Please add a linear GJK regression alongside the angular one.

The suite covers linear motion only on sphere-based paths and covers boxes only with angular motion. A simple approaching/diverging box-box case would exercise the remaining non-primitive speculative acceptance branch and would catch regressions in the directed write_contact() path much earlier.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/tests/test_speculative_contacts.py` around lines 138 - 165, Add a
second test inside (or next to) test_speculative_angular_velocity that performs
a linear GJK regression for box-box speculative contacts: reuse the
newton.ModelBuilder setup (builder, builder.rigid_gap = 0.0), create two box
bodies (similar to body_a and body_b) positioned apart on the x-axis, set their
body_qd linear velocity components so they are approaching each other (e.g.,
nonzero vx on body_a or opposite vx on body_b) while keeping angular rates zero,
finalize the model and state, create the same newton.SpeculativeContactConfig
and newton.CollisionPipeline (broad_phase="nxn", speculative_config=config),
call pipeline.collide(state, contacts) and assert
contacts.rigid_contact_count.numpy()[0] > 0 to validate linear speculative
acceptance; name the test to indicate linear GJK regression (e.g.,
test_speculative_linear_velocity) so it exercises the non-primitive speculative
acceptance branch and directed write_contact path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@newton/_src/geometry/narrow_phase.py`:
- Around line 242-245: The current speculative widening uses raw relative speed
(vel_rel / |v_rel| via rel_speed) to increase gap_sum (using vel_rel, rel_speed,
gap_sum, speculative_dt, max_speculative_extension), which can accept
diverging/tangential pairs; change this so the rel_speed-based widening is used
only for candidate generation and not as the final acceptance metric: keep the
existing conservative addition to gap_sum for early pruning in the speculative
branch (both where vel_rel/rel_speed is used and in the duplicate primitive path
around the same logic), but ensure the final contact_passes_gap_check uses the
approach-speed projected onto the resolved contact normal (compute approach =
dot(vel_rel, contact_normal) + angular bounds) and use that projection for the
final gap acceptance instead of |v_rel|.

In `@newton/_src/sim/collide.py`:
- Around line 181-205: The speculative AABB kernel compute_shape_aabbs never
applies the configured max_speculative_extension, so add a kernel parameter
(e.g. max_speculative_extension: float) to compute_shape_aabbs (and the other
similar kernel variants referenced around the other occurrences) and clamp the
computed speculative expansion to that value before adding it to
aabb_lower/aabb_upper; locate where shape_lin_vel/shape_ang_speed_bound and
speculative_dt are used to compute the linear/angular expansions and insert a
min(extension, max_speculative_extension) (or equivalent) there so the
broad-phase expansion is bounded by the user-configured cap.
- Around line 31-47: Ensure speculative parameters are validated and reject
invalid values before any kernel/AABB math: add checks that
SpeculativeContactConfig.max_speculative_extension and
SpeculativeContactConfig.collision_update_dt are finite and non-negative (and >0
for dt) when creating/using the config, and in CollisionPipeline.collide(dt=...)
validate the per-call dt similarly; if any value is negative, zero, or
non-finite, raise a clear exception (or disable speculative extension
explicitly) so the invalid values never flow into AABB/gap computations or
kernel launches.
- Around line 123-136: The approach-speed projection is reversed: compute
approach speed so approaching motion yields a positive value and only then
extend the speculative gap. Change the v_approach calculation to use the negated
linear projection (e.g., use wp.dot(vel_a - vel_b, contact_normal_a_to_b) or
negate wp.dot(vel_b - vel_a, contact_normal_a_to_b)) while keeping the angular
bounds, then compute spec_gap = wp.min(wp.max(v_approach *
writer_data.speculative_dt, 0.0), writer_data.max_speculative_extension) and add
it to contact_gap; update the v_approach variable and usage accordingly
(symbols: writer_data.speculative_dt, vel_a, vel_b, contact_normal_a_to_b,
v_approach, spec_gap, writer_data.max_speculative_extension, contact_gap).

---

Nitpick comments:
In `@newton/tests/test_speculative_contacts.py`:
- Around line 138-165: Add a second test inside (or next to)
test_speculative_angular_velocity that performs a linear GJK regression for
box-box speculative contacts: reuse the newton.ModelBuilder setup (builder,
builder.rigid_gap = 0.0), create two box bodies (similar to body_a and body_b)
positioned apart on the x-axis, set their body_qd linear velocity components so
they are approaching each other (e.g., nonzero vx on body_a or opposite vx on
body_b) while keeping angular rates zero, finalize the model and state, create
the same newton.SpeculativeContactConfig and newton.CollisionPipeline
(broad_phase="nxn", speculative_config=config), call pipeline.collide(state,
contacts) and assert contacts.rigid_contact_count.numpy()[0] > 0 to validate
linear speculative acceptance; name the test to indicate linear GJK regression
(e.g., test_speculative_linear_velocity) so it exercises the non-primitive
speculative acceptance branch and directed write_contact path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 789f46d6-07bb-4f8e-a93c-f470a41c5965

📥 Commits

Reviewing files that changed from the base of the PR and between 0910bd5 and fed33dc.

📒 Files selected for processing (6)
  • newton/__init__.py
  • newton/_src/geometry/narrow_phase.py
  • newton/_src/sim/__init__.py
  • newton/_src/sim/collide.py
  • newton/_src/sim/model.py
  • newton/tests/test_speculative_contacts.py

Comment thread newton/_src/geometry/narrow_phase.py
Comment thread newton/_src/sim/collide.py
Comment thread newton/_src/sim/collide.py Outdated
Comment thread newton/_src/sim/collide.py
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/concepts/collisions.rst`:
- Around line 1120-1122: The docs sentence claiming speculative widening uses a
"directed approach speed along the contact normal" is incorrect; update the
description to reflect that the narrow-phase widening uses the sum of relative
linear speed magnitude and angular-speed bounds (then clamped by
max_speculative_extension) rather than a normal-projected approach term —
mention the actual computation (relative linear speed magnitude + angular-speed
bounds, clamped by max_speculative_extension) and remove or correct the phrase
"directed approach speed along the contact normal" so readers get accurate
tuning guidance referencing max_speculative_extension and the angular-speed
bounds logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 065ae639-d5f5-4033-9bbc-7c6b670c599e

📥 Commits

Reviewing files that changed from the base of the PR and between 223a5b4 and 5e3aece.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • docs/concepts/collisions.rst
  • newton/_src/sim/collide.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • newton/_src/sim/collide.py

Comment thread docs/concepts/collisions.rst Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
newton/_src/geometry/narrow_phase.py (1)

1768-1772: Consider caching the empty speculative arrays.

These empty arrays are allocated on every launch_custom_write call. Since NarrowPhase already caches similar sentinel buffers (e.g., _empty_edge_indices, _empty_edge_range), the speculative fallback arrays could be cached in __init__ to avoid repeated allocations.

♻️ Suggested caching in __init__
 # In __init__, after allocating other sentinel buffers:
+            # Sentinel arrays for speculative fallback (when not provided)
+            self._empty_lin_vel = wp.empty(0, dtype=wp.vec3, device=device)
+            self._empty_ang_speed = wp.empty(0, dtype=wp.float32, device=device)
 # In launch_custom_write:
-        _empty_vec3 = wp.empty(0, dtype=wp.vec3, device=device)
-        _empty_float = wp.empty(0, dtype=wp.float32, device=device)
-        _slv = shape_lin_vel if shape_lin_vel is not None else _empty_vec3
-        _sasb = shape_ang_speed_bound if shape_ang_speed_bound is not None else _empty_float
+        _slv = shape_lin_vel if shape_lin_vel is not None else self._empty_lin_vel
+        _sasb = shape_ang_speed_bound if shape_ang_speed_bound is not None else self._empty_ang_speed
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/_src/geometry/narrow_phase.py` around lines 1768 - 1772, Create and
cache the empty speculative arrays in NarrowPhase.__init__ (e.g.,
self._empty_spec_vec3 = wp.empty(0, dtype=wp.vec3, device=self.device) and
self._empty_spec_float = wp.empty(0, dtype=wp.float32, device=self.device) using
the same device as the instance), then update launch_custom_write to use those
cached sentinels instead of allocating new ones: replace local allocations of
_empty_vec3/_empty_float with the cached attributes and keep the same fallback
logic for _slv (shape_lin_vel) and _sasb (shape_ang_speed_bound). Ensure the
cached arrays match dtype and device so existing code that references _slv/_sasb
continues to work.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@newton/_src/geometry/narrow_phase.py`:
- Around line 1768-1772: Create and cache the empty speculative arrays in
NarrowPhase.__init__ (e.g., self._empty_spec_vec3 = wp.empty(0, dtype=wp.vec3,
device=self.device) and self._empty_spec_float = wp.empty(0, dtype=wp.float32,
device=self.device) using the same device as the instance), then update
launch_custom_write to use those cached sentinels instead of allocating new
ones: replace local allocations of _empty_vec3/_empty_float with the cached
attributes and keep the same fallback logic for _slv (shape_lin_vel) and _sasb
(shape_ang_speed_bound). Ensure the cached arrays match dtype and device so
existing code that references _slv/_sasb continues to work.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 3b97f6cd-435e-430f-8a20-26d558595cfb

📥 Commits

Reviewing files that changed from the base of the PR and between 5e3aece and 89cbdf6.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • docs/concepts/collisions.rst
  • newton/_src/geometry/narrow_phase.py
  • newton/_src/sim/collide.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • CHANGELOG.md
  • docs/concepts/collisions.rst

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
newton/_src/geometry/contact_sort.py (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Keep the original SPDX creation year here.

newton/_src/geometry/contact_sort.py is an existing file, so changing this header to 2026 loses the file's creation year.

As per coding guidelines, "**/*.{py,yml,yaml}: In SPDX copyright lines, use the year the file was first created. Do not create date ranges or update the year when modifying a file."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/_src/geometry/contact_sort.py` at line 1, The SPDX header in
newton/_src/geometry/contact_sort.py was changed to 2026 but must retain the
file's original creation year; revert the SPDX-FileCopyrightText line in
contact_sort.py to the original single year (not a range) that the file was
first created so the header matches repository policy.
♻️ Duplicate comments (4)
newton/_src/geometry/narrow_phase.py (2)

880-894: ⚠️ Potential issue | 🟠 Major

Speculative heightfield pairs still use the non-speculative midphase.

This branch still calls heightfield_vs_convex_midphase() with only shape_gap, so the speculative extension never widens the heightfield cell query. Fast convexs can still cross into a new heightfield cell between frames without emitting the triangle pairs needed for the later GJK/MPR pass.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/_src/geometry/narrow_phase.py` around lines 880 - 894, The heightfield
branch is calling heightfield_vs_convex_midphase with only shape_gap, so
speculative extension isn't applied; change the call to pass the
speculative-widened gap (e.g., shape_gap + shape_speculative_extension or a
dedicated shape_speculative_gap) and update heightfield_vs_convex_midphase's
signature/uses to accept and apply that speculative gap when building the
heightfield cell query; keep the j!=0 early-continue logic and locate this
change around GeoType.HFIELD, heightfield_data, shape_heightfield_index and the
heightfield_vs_convex_midphase invocation.

1835-1862: ⚠️ Potential issue | 🟠 Major

NarrowPhase.launch() still can't drive speculative contacts.

launch_custom_write() now accepts shape_lin_vel, shape_ang_speed_bound, speculative_dt, and max_speculative_extension, but this wrapper neither exposes nor forwards them. Any caller that still uses launch() has no path to enable the new feature.

🧩 Suggested fix
     def launch(
         self,
         *,
@@
         contact_penetration: wp.array[float],  # negative if bodies overlap
         contact_count: wp.array[int],  # Number of active contacts after narrow
         contact_tangent: wp.array[wp.vec3] | None = None,  # Represents x axis of local contact frame (None to disable)
+        shape_lin_vel: wp.array[wp.vec3] | None = None,
+        shape_ang_speed_bound: wp.array[wp.float32] | None = None,
+        speculative_dt: float = 0.0,
+        max_speculative_extension: float = 0.0,
         device: Devicelike | None = None,  # Device to launch on
         **kwargs: Any,
     ) -> None:
@@
         self.launch_custom_write(
             candidate_pair=candidate_pair,
             candidate_pair_count=candidate_pair_count,
@@
             mesh_edge_indices=mesh_edge_indices,
             shape_edge_range=shape_edge_range,
             writer_data=writer_data,
+            shape_lin_vel=shape_lin_vel,
+            shape_ang_speed_bound=shape_ang_speed_bound,
+            speculative_dt=speculative_dt,
+            max_speculative_extension=max_speculative_extension,
             device=device,
         )

Also applies to: 2312-2448

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/_src/geometry/narrow_phase.py` around lines 1835 - 1862, The
NarrowPhase.launch() wrapper does not expose or forward the new
speculative-contact parameters to launch_custom_write (shape_lin_vel,
shape_ang_speed_bound, speculative_dt, max_speculative_extension), so callers
cannot enable speculative contacts; update the NarrowPhase.launch() signature to
accept those four parameters (with same defaults) and pass them through when
calling launch_custom_write, and repeat the same change for the other wrapper
overload around the later block (the one covering lines ~2312-2448) so both
wrappers expose and forward shape_lin_vel, shape_ang_speed_bound,
speculative_dt, and max_speculative_extension to launch_custom_write.
newton/_src/sim/collide.py (2)

928-935: ⚠️ Potential issue | 🟠 Major

Deterministic mode still enables for hydroelastic scenes.

The docstring warns that hydroelastic contacts are not yet covered, but this branch still enables deterministic sorting for them — and in hydroelastic write paths multiple contacts per shape pair share the same sort key, so their relative order remains launch-dependent and results in nondeterministic rigid_contact_* buffers. Additionally, since enabling contact_matching now force-sets deterministic=True, a user could unintentionally activate this path on a hydroelastic scene just by opting into matching.

Consider rejecting the combination explicitly:

🛡️ Suggested guard
         self.requires_grad = requires_grad
         self.deterministic = deterministic
         per_contact_props = self.narrow_phase.hydroelastic_sdf is not None
         if deterministic:
+            if per_contact_props:
+                raise ValueError(
+                    "CollisionPipeline(deterministic=True) does not support "
+                    "hydroelastic contacts yet."
+                )
             with wp.ScopedDevice(device):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/_src/sim/collide.py` around lines 928 - 935, The deterministic branch
is being enabled for hydroelastic scenes (per_contact_props is True when
self.narrow_phase.hydroelastic_sdf is set), which is invalid because
hydroelastic contacts produce multiple contacts per pair that keep
launch-dependent ordering; update the guard in the constructor around the
deterministic/contact-sorting logic (the block creating self._sort_key_array and
self._contact_sorter/ContactSorter) to explicitly reject or disable
deterministic mode when self.narrow_phase.hydroelastic_sdf is present (and also
when contact_matching forces deterministic=True), e.g. raise a clear exception
or fallback to deterministic=False if per_contact_props is True; reference the
deterministic flag, per_contact_props, self.narrow_phase.hydroelastic_sdf,
ContactSorter and the code path that force-sets deterministic via
contact_matching to locate and adjust the logic.

767-810: ⚠️ Potential issue | 🟠 Major

Speculative setting mismatch still not validated in the expert-components branch.

When a caller supplies a prebuilt narrow_phase, the constructor only threads speculative=self._speculative_enabled through in the non-expert path (line 894). In the expert branch this check is skipped: if the passed-in narrow_phase was built with speculative=False but speculative_config is not None, speculative expansion in write_contact/AABBs silently no-ops; if it was built with speculative=True but speculative_config is None, collide() still passes empty shape_lin_vel/shape_ang_speed_bound arrays, and any kernel compiled to index them will OOB.

Please validate the supplied narrow phase’s speculative setting against self._speculative_enabled here, before the sanity checks on max_candidate_pairs:

🛡️ Suggested guard
             if deterministic and not narrow_phase.deterministic:
                 raise ValueError(
                     "CollisionPipeline(deterministic=True) requires a deterministic "
                     "NarrowPhase. Either omit narrow_phase or construct it with "
                     "deterministic=True."
                 )
+            if getattr(narrow_phase, "speculative", False) != self._speculative_enabled:
+                raise ValueError(
+                    "speculative_config must match the supplied NarrowPhase.speculative "
+                    f"setting (config enabled={self._speculative_enabled}, "
+                    f"narrow_phase.speculative={getattr(narrow_phase, 'speculative', False)})"
+                )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/_src/sim/collide.py` around lines 767 - 810, The expert-component
branch fails to validate that the supplied NarrowPhase's speculative setting
matches the pipeline's speculative enablement; before the max_candidate_pairs
check, assert that the provided narrow_phase has a boolean attribute speculative
and that narrow_phase.speculative == self._speculative_enabled, and if not raise
a ValueError describing the mismatch (e.g., ask to pass a narrow_phase built
with speculative matching CollisionPipeline(_speculative_enabled) or adjust
speculative_config); reference the symbols narrow_phase,
self._speculative_enabled, and self.narrow_phase and perform this check prior to
the existing narrow_phase.max_candidate_pairs validation.
🧹 Nitpick comments (4)
newton/_src/sim/collide.py (2)

1067-1075: Validate the per-call dt override before using it.

speculative_dt = dt if dt is not None else cfg.collision_update_dt executes before validation, so a negative/NaN caller-supplied dt is already bound to speculative_dt by the time the check runs. The ValueError still fires before any kernel launch so this is not an active bug, but flipping the order makes the intent clearer and prevents future refactors from accidentally using the unchecked value:

🧹 Suggested reorder
-        if self._speculative_enabled:
-            cfg = self.speculative_config
-            speculative_dt = dt if dt is not None else cfg.collision_update_dt
-            if dt is not None:
-                _validate_speculative_scalar(dt, "dt")
-            max_speculative_extension = cfg.max_speculative_extension
+        if self._speculative_enabled:
+            cfg = self.speculative_config
+            if dt is not None:
+                _validate_speculative_scalar(dt, "dt")
+            speculative_dt = dt if dt is not None else cfg.collision_update_dt
+            max_speculative_extension = cfg.max_speculative_extension
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/_src/sim/collide.py` around lines 1067 - 1075, The per-call dt should
be validated before being assigned to speculative_dt; move the
_validate_speculative_scalar(dt, "dt") check to occur when dt is not None and
before computing speculative_dt in the block that checks
self._speculative_enabled (using speculative_config, speculative_dt, and
max_speculative_extension) so that any caller-supplied dt is validated first and
only then used to set speculative_dt (falling back to cfg.collision_update_dt
when dt is None).

319-328: Duplicate speculative AABB expansion block.

The 10-line directed+angular speculative expansion is inlined twice (has_local_aabb branch and support-function branch). Factoring it into a small @wp.func helper would make both branches easier to keep in sync if bounds math evolves:

♻️ Sketch
`@wp.func`
def _apply_speculative_extension(
    lo: wp.vec3, hi: wp.vec3,
    v: wp.vec3, ang_ext: float,
    speculative_dt: float, cap_scalar: float,
) -> tuple[wp.vec3, wp.vec3]:
    vel_dt = v * speculative_dt
    ang_vec = wp.vec3(ang_ext, ang_ext, ang_ext)
    cap = wp.vec3(cap_scalar, cap_scalar, cap_scalar)
    ext_neg = wp.max(-vel_dt, wp.vec3(0.0, 0.0, 0.0))
    ext_pos = wp.max(vel_dt, wp.vec3(0.0, 0.0, 0.0))
    return lo - wp.min(ext_neg + ang_vec, cap), hi + wp.min(ext_pos + ang_vec, cap)

Also applies to: 353-362

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/_src/sim/collide.py` around lines 319 - 328, There is duplicated
speculative AABB expansion logic in both the has_local_aabb and support-function
branches; extract it into a small `@wp.func` (e.g. _apply_speculative_extension)
that takes lo, hi, v (use shape_lin_vel[shape_id]), ang_ext (use
shape_ang_speed_bound[shape_id] * speculative_dt or pass ang_ext scalar),
speculative_dt and cap_scalar (max_speculative_extension) and returns the new
(lo, hi) after computing vel_dt, ang_vec, cap, ext_neg/ext_pos and applying
wp.min/wp.max as in the sketch; replace the duplicated blocks (the one using
shape_lin_vel/shape_ang_speed_bound and the one in the support-function branch)
to call this helper so both branches share the same logic.
newton/_src/geometry/contact_match.py (2)

281-288: Tentative match_index is written even for claim losers.

Pass 1 writes data.match_index[tid] = wp.int32(best_idx) before racing for ownership via atomic_min. Until _resolve_claims_kernel runs, every contender for the same prev[best_idx] holds a transient match_index[tid] == best_idx; only the winner keeps it, the rest are demoted to MATCH_BROKEN in Pass 2. This is correct because both kernels launch on the same stream and the resolve pass runs unconditionally — just worth a one-line comment noting that the intermediate value is only valid after _resolve_claims_kernel completes, to prevent someone from adding an early read of match_index between the two launches.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/_src/geometry/contact_match.py` around lines 281 - 288, The current
code writes a tentative match into data.match_index[tid] before racing via
wp.atomic_min on data.prev_claim, so losers will temporarily hold best_idx until
_resolve_claims_kernel runs and demotes them to MATCH_BROKEN; update the block
around match_index assignment (referencing data.match_index, wp.atomic_min,
data.prev_claim, _pack_claim, and _resolve_claims_kernel) to include a concise
one-line comment clarifying that the written match_index is tentative and only
valid after _resolve_claims_kernel completes, warning future readers not to read
match_index between the two kernel launches.

828-836: Dummy aliases in the non-sticky path stomp scratch_pos_world across fields.

When sticky=False, every sticky-only slot (src_offset0/1, dst_point0_body, dst_point1_body, dst_offset0_body, dst_offset1_body, dst_normal_sticky) is aliased to the same self._sorter.scratch_pos_world buffer. The kernel is gated by has_sticky==0 so these slots are never read or written, which makes this safe today — but assigning five dst_* outputs to the same array makes static/runtime alias analyzers nervous, and a future refactor that drops (or partially drops) the has_sticky guard would silently produce concurrent writes to one buffer.

Consider either using a single shared dummy alias consistently named (e.g. _dummy_vec3 = wp.empty(1, dtype=wp.vec3) stored on the matcher) or leaving a comment right above this block noting that these aliases are only safe so long as has_sticky gates all reads/writes in _save_sorted_state_kernel.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/_src/geometry/contact_match.py` around lines 828 - 836, The dummy
aliases for sticky-only fields in _save_sorted_state_kernel currently all point
to self._sorter.scratch_pos_world which can confuse alias analyzers and risks
future concurrent writes; create a single dedicated dummy buffer on the matcher
(e.g. self._dummy_vec3 = wp.empty(1, dtype=wp.vec3) initialized in the
matcher/constructor) and assign data.src_offset0, data.src_offset1,
data.dst_point0_body, data.dst_point1_body, data.dst_offset0_body,
data.dst_offset1_body, and data.dst_normal_sticky to that _dummy_vec3 when
sticky=False, and keep data.has_sticky = 0 (or alternatively add a clear comment
above this block referencing _save_sorted_state_kernel and the has_sticky guard
if you prefer not to add a buffer).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CHANGELOG.md`:
- Around line 104-105: The two bullets referencing
SpeculativeContactConfig/CollisionPipeline and TRIANGLE_PRISM were mistakenly
placed under the released "## [1.1.0] - 2026-04-13" section; move those two
lines out of the 1.1.0 section and add them under the "## [Unreleased]" section
instead. Specifically, remove the entries mentioning "SpeculativeContactConfig"
and "CollisionPipeline" and the "TRIANGLE_PRISM" support-function type from the
1.1.0 list and paste them into the Unreleased section as separate bullet items
so the changelog no longer retroactively changes shipped release notes and the
TRIANGLE_PRISM entry appears only once.

In `@newton/_src/sim/contacts.py`:
- Around line 242-255: The per-frame device counters rigid_contact_new_count and
rigid_contact_broken_count are not reset by clear(), leaving stale report
values; update clear() to explicitly zero these two counters (in the same way
contact_counters are reset) so that rigid_contact_new_count and
rigid_contact_broken_count are set to 0 each clear call; ensure you handle both
places where these fields are initialized/used (the block with
rigid_contact_new_indices/new_count and the similar block around lines 293-345)
and use the same device-zeroing semantics as the other contact counters.

---

Outside diff comments:
In `@newton/_src/geometry/contact_sort.py`:
- Line 1: The SPDX header in newton/_src/geometry/contact_sort.py was changed to
2026 but must retain the file's original creation year; revert the
SPDX-FileCopyrightText line in contact_sort.py to the original single year (not
a range) that the file was first created so the header matches repository
policy.

---

Duplicate comments:
In `@newton/_src/geometry/narrow_phase.py`:
- Around line 880-894: The heightfield branch is calling
heightfield_vs_convex_midphase with only shape_gap, so speculative extension
isn't applied; change the call to pass the speculative-widened gap (e.g.,
shape_gap + shape_speculative_extension or a dedicated shape_speculative_gap)
and update heightfield_vs_convex_midphase's signature/uses to accept and apply
that speculative gap when building the heightfield cell query; keep the j!=0
early-continue logic and locate this change around GeoType.HFIELD,
heightfield_data, shape_heightfield_index and the heightfield_vs_convex_midphase
invocation.
- Around line 1835-1862: The NarrowPhase.launch() wrapper does not expose or
forward the new speculative-contact parameters to launch_custom_write
(shape_lin_vel, shape_ang_speed_bound, speculative_dt,
max_speculative_extension), so callers cannot enable speculative contacts;
update the NarrowPhase.launch() signature to accept those four parameters (with
same defaults) and pass them through when calling launch_custom_write, and
repeat the same change for the other wrapper overload around the later block
(the one covering lines ~2312-2448) so both wrappers expose and forward
shape_lin_vel, shape_ang_speed_bound, speculative_dt, and
max_speculative_extension to launch_custom_write.

In `@newton/_src/sim/collide.py`:
- Around line 928-935: The deterministic branch is being enabled for
hydroelastic scenes (per_contact_props is True when
self.narrow_phase.hydroelastic_sdf is set), which is invalid because
hydroelastic contacts produce multiple contacts per pair that keep
launch-dependent ordering; update the guard in the constructor around the
deterministic/contact-sorting logic (the block creating self._sort_key_array and
self._contact_sorter/ContactSorter) to explicitly reject or disable
deterministic mode when self.narrow_phase.hydroelastic_sdf is present (and also
when contact_matching forces deterministic=True), e.g. raise a clear exception
or fallback to deterministic=False if per_contact_props is True; reference the
deterministic flag, per_contact_props, self.narrow_phase.hydroelastic_sdf,
ContactSorter and the code path that force-sets deterministic via
contact_matching to locate and adjust the logic.
- Around line 767-810: The expert-component branch fails to validate that the
supplied NarrowPhase's speculative setting matches the pipeline's speculative
enablement; before the max_candidate_pairs check, assert that the provided
narrow_phase has a boolean attribute speculative and that
narrow_phase.speculative == self._speculative_enabled, and if not raise a
ValueError describing the mismatch (e.g., ask to pass a narrow_phase built with
speculative matching CollisionPipeline(_speculative_enabled) or adjust
speculative_config); reference the symbols narrow_phase,
self._speculative_enabled, and self.narrow_phase and perform this check prior to
the existing narrow_phase.max_candidate_pairs validation.

---

Nitpick comments:
In `@newton/_src/geometry/contact_match.py`:
- Around line 281-288: The current code writes a tentative match into
data.match_index[tid] before racing via wp.atomic_min on data.prev_claim, so
losers will temporarily hold best_idx until _resolve_claims_kernel runs and
demotes them to MATCH_BROKEN; update the block around match_index assignment
(referencing data.match_index, wp.atomic_min, data.prev_claim, _pack_claim, and
_resolve_claims_kernel) to include a concise one-line comment clarifying that
the written match_index is tentative and only valid after _resolve_claims_kernel
completes, warning future readers not to read match_index between the two kernel
launches.
- Around line 828-836: The dummy aliases for sticky-only fields in
_save_sorted_state_kernel currently all point to self._sorter.scratch_pos_world
which can confuse alias analyzers and risks future concurrent writes; create a
single dedicated dummy buffer on the matcher (e.g. self._dummy_vec3 =
wp.empty(1, dtype=wp.vec3) initialized in the matcher/constructor) and assign
data.src_offset0, data.src_offset1, data.dst_point0_body, data.dst_point1_body,
data.dst_offset0_body, data.dst_offset1_body, and data.dst_normal_sticky to that
_dummy_vec3 when sticky=False, and keep data.has_sticky = 0 (or alternatively
add a clear comment above this block referencing _save_sorted_state_kernel and
the has_sticky guard if you prefer not to add a buffer).

In `@newton/_src/sim/collide.py`:
- Around line 1067-1075: The per-call dt should be validated before being
assigned to speculative_dt; move the _validate_speculative_scalar(dt, "dt")
check to occur when dt is not None and before computing speculative_dt in the
block that checks self._speculative_enabled (using speculative_config,
speculative_dt, and max_speculative_extension) so that any caller-supplied dt is
validated first and only then used to set speculative_dt (falling back to
cfg.collision_update_dt when dt is None).
- Around line 319-328: There is duplicated speculative AABB expansion logic in
both the has_local_aabb and support-function branches; extract it into a small
`@wp.func` (e.g. _apply_speculative_extension) that takes lo, hi, v (use
shape_lin_vel[shape_id]), ang_ext (use shape_ang_speed_bound[shape_id] *
speculative_dt or pass ang_ext scalar), speculative_dt and cap_scalar
(max_speculative_extension) and returns the new (lo, hi) after computing vel_dt,
ang_vec, cap, ext_neg/ext_pos and applying wp.min/wp.max as in the sketch;
replace the duplicated blocks (the one using shape_lin_vel/shape_ang_speed_bound
and the one in the support-function branch) to call this helper so both branches
share the same logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 3e5cd8ec-242d-4034-b1b2-2a8ee69a1e02

📥 Commits

Reviewing files that changed from the base of the PR and between b739859 and 3c67048.

📒 Files selected for processing (15)
  • CHANGELOG.md
  • docs/api/newton.rst
  • docs/api/newton_geometry.rst
  • docs/concepts/collisions.rst
  • newton/__init__.py
  • newton/_src/geometry/__init__.py
  • newton/_src/geometry/contact_match.py
  • newton/_src/geometry/contact_reduction_global.py
  • newton/_src/geometry/contact_sort.py
  • newton/_src/geometry/narrow_phase.py
  • newton/_src/sim/collide.py
  • newton/_src/sim/contacts.py
  • newton/_src/sim/model.py
  • newton/geometry.py
  • newton/tests/test_contact_matching.py
✅ Files skipped from review due to trivial changes (4)
  • docs/api/newton.rst
  • newton/geometry.py
  • docs/api/newton_geometry.rst
  • newton/_src/geometry/init.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • newton/init.py
  • newton/_src/sim/model.py
  • docs/concepts/collisions.rst
  • newton/_src/geometry/contact_reduction_global.py

Comment thread CHANGELOG.md Outdated
Comment thread newton/_src/sim/contacts.py
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
newton/tests/test_speculative_contacts.py (2)

117-138: contacts.clear() at line 135 is redundant with the pipeline's own clearing.

Per CollisionPipeline.collide(), the contact count is reset inside compute_shape_aabbs on every call (the generation bump fuses the clear), and an explicit contacts.clear() is only needed in the clear_buffers debug path. Calling it here isn't wrong, but it suggests the test believes the second collide() wouldn't otherwise reset the counter — which it does. Safe to drop, or add a brief comment noting why the explicit clear is kept.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/tests/test_speculative_contacts.py` around lines 117 - 138, The
explicit call to contacts.clear() in test_speculative_dt_override is redundant
because CollisionPipeline.collide() resets contact buffers (via
compute_shape_aabbs) on each call; remove the contacts.clear() invocation (or if
you prefer to keep it for clarity, replace it with a one-line comment
referencing CollisionPipeline.collide()/compute_shape_aabbs to explain why it’s
unnecessary) so the test does not imply collide() fails to reset the contact
count.

467-503: Bare class bodies + module-level test registration — works, but fragile.

TestSpeculativeContacts and TestSpeculativeTunneling have no body (just a docstring), and the test methods are attached at module import time by the for … add_function_test(…) loops below. That means:

  • If this module is imported with devices unavailable (e.g. get_cuda_test_devices() returning []), TestSpeculativeTunneling becomes an empty class that the @unittest.skipUnless(_cuda_available, …) wrapper still works for, but without any registered methods unittest just reports no tests (no visible skip). That's usually fine, but the class-level skipUnless is then purely decorative.
  • Helper functions such as test_speculative_disabled_no_contacts are defined at module top level with a test_* prefix. If this file is ever discovered directly by a plain unittest/pytest loader (not via add_function_test), those top-level functions can be picked up as standalone tests and will fail because they take (test, device). Consider renaming the helpers (e.g. _test_… or _run_…) or moving them inside the class so they aren't accidentally collected.

Not a blocker — just worth tightening before de-drafting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/tests/test_speculative_contacts.py` around lines 467 - 503, Top-level
helper functions like test_speculative_disabled_no_contacts,
test_speculative_enabled_catches_fast_object, etc. are named with a test_*
prefix and can be discovered by unittest/pytest; also TestSpeculativeContacts
and TestSpeculativeTunneling are bare classes relying on add_function_test to
attach methods at import time which is fragile when device lists are empty. Fix
by renaming the helper functions to a non-discovered prefix (e.g. _test_ or
_run_) or move them inside the TestSpeculativeContacts/TestSpeculativeTunneling
classes, and add a minimal no-op test method (e.g. def test_placeholder(self):
pass) to each class so the `@unittest.skipUnless` on TestSpeculativeTunneling
still produces a visible skip when CUDA is unavailable; update usage of
add_function_test accordingly to reference the renamed functions if you choose
renaming.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@newton/tests/test_speculative_contacts.py`:
- Around line 247-314: The triangle winding in _make_thin_wall_mesh is inverted
causing inward normals; fix by reversing the vertex order for every triangle in
the tris array used by _make_thin_wall_mesh (i.e., swap the winding of each
triple so each face's two triangles have their indices reversed) so that face
normals point outward; update the tris definition in _make_thin_wall_mesh
accordingly so compute_sdf_from_mesh sees the correct inside/outside
orientation.

---

Nitpick comments:
In `@newton/tests/test_speculative_contacts.py`:
- Around line 117-138: The explicit call to contacts.clear() in
test_speculative_dt_override is redundant because CollisionPipeline.collide()
resets contact buffers (via compute_shape_aabbs) on each call; remove the
contacts.clear() invocation (or if you prefer to keep it for clarity, replace it
with a one-line comment referencing
CollisionPipeline.collide()/compute_shape_aabbs to explain why it’s unnecessary)
so the test does not imply collide() fails to reset the contact count.
- Around line 467-503: Top-level helper functions like
test_speculative_disabled_no_contacts,
test_speculative_enabled_catches_fast_object, etc. are named with a test_*
prefix and can be discovered by unittest/pytest; also TestSpeculativeContacts
and TestSpeculativeTunneling are bare classes relying on add_function_test to
attach methods at import time which is fragile when device lists are empty. Fix
by renaming the helper functions to a non-discovered prefix (e.g. _test_ or
_run_) or move them inside the TestSpeculativeContacts/TestSpeculativeTunneling
classes, and add a minimal no-op test method (e.g. def test_placeholder(self):
pass) to each class so the `@unittest.skipUnless` on TestSpeculativeTunneling
still produces a visible skip when CUDA is unavailable; update usage of
add_function_test accordingly to reference the renamed functions if you choose
renaming.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 2b942b3f-8ad3-4990-8ce0-b48b824d69c6

📥 Commits

Reviewing files that changed from the base of the PR and between 32e0bff and 312d9a1.

📒 Files selected for processing (1)
  • newton/tests/test_speculative_contacts.py

Comment on lines +247 to +314
def _make_thin_wall_mesh(hx=0.02, hy=1.0, hz=1.0):
"""Return a :class:`newton.Mesh` representing a thin box (wall).

The wall is centred at the origin with half-extents ``(hx, hy, hz)``.
Winding is CCW when viewed from the +X side so that the face normals
point outward.
"""
verts = np.array(
[
[-hx, -hy, -hz],
[hx, -hy, -hz],
[hx, hy, -hz],
[-hx, hy, -hz],
[-hx, -hy, hz],
[hx, -hy, hz],
[hx, hy, hz],
[-hx, hy, hz],
],
dtype=np.float32,
)
tris = np.array(
[
# -X face
0,
3,
7,
0,
7,
4,
# +X face
1,
5,
6,
1,
6,
2,
# -Y face
0,
4,
5,
0,
5,
1,
# +Y face
3,
2,
6,
3,
6,
7,
# -Z face
0,
1,
2,
0,
2,
3,
# +Z face
4,
7,
6,
4,
6,
5,
],
dtype=np.int32,
)
return newton.Mesh(verts, tris, compute_inertia=False)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Recompute the outward normals of each face from the arrays defined in
# _make_thin_wall_mesh to confirm which direction they actually point.
python - <<'PY'
import numpy as np
hx, hy, hz = 0.02, 1.0, 1.0
verts = np.array([
    [-hx, -hy, -hz],
    [ hx, -hy, -hz],
    [ hx,  hy, -hz],
    [-hx,  hy, -hz],
    [-hx, -hy,  hz],
    [ hx, -hy,  hz],
    [ hx,  hy,  hz],
    [-hx,  hy,  hz],
])
tris = np.array([
    [0,3,7],[0,7,4],   # -X
    [1,5,6],[1,6,2],   # +X
    [0,4,5],[0,5,1],   # -Y
    [3,2,6],[3,6,7],   # +Y
    [0,1,2],[0,2,3],   # -Z
    [4,7,6],[4,6,5],   # +Z
])
labels = ["-X","-X","+X","+X","-Y","-Y","+Y","+Y","-Z","-Z","+Z","+Z"]
for lbl, t in zip(labels, tris):
    a, b, c = verts[t[0]], verts[t[1]], verts[t[2]]
    n = np.cross(b - a, c - a)
    n = n / np.linalg.norm(n)
    print(f"face {lbl} tri {t.tolist()} -> normal {n}")
PY

Repository: newton-physics/newton

Length of output: 599


Triangle winding in _make_thin_wall_mesh is completely inverted — all face normals point inward instead of outward.

All 12 triangles (6 faces × 2 tris per face) have normals pointing toward the box interior:

  • -X face: computed normal is +X (should be -X)
  • +X face: computed normal is -X (should be +X)
  • -Y face: computed normal is +Y (should be -Y)
  • +Y face: computed normal is -Y (should be +Y)
  • -Z face: computed normal is +Z (should be -Z)
  • +Z face: computed normal is -Z (should be +Z)

The docstring's claim that winding produces outward normals is false. This breaks the mesh-based SDF collision path (_run_mesh_box_vs_mesh_wall_sim line 415) because compute_sdf_from_mesh will build a signed-distance field with inside/outside semantics reversed. The speculative gap and penetration calculations will operate on an inverted SDF, making collision detection unreliable.

Fix by inverting each triangle's vertex order:

Suggested fix
     tris = np.array(
         [
             # -X face
             0,
-            3,
-            7,
+            7,
+            3,
             0,
-            7,
             4,
+            7,
             # +X face
             1,
-            5,
-            6,
+            6,
+            5,
             1,
-            6,
             2,
+            6,
             # -Y face
             0,
-            4,
-            5,
+            5,
+            4,
             0,
-            5,
             1,
+            5,
             # +Y face
             3,
-            2,
-            6,
+            6,
+            2,
             3,
-            6,
             7,
+            6,
             # -Z face
             0,
-            1,
-            2,
+            2,
+            1,
             0,
-            2,
             3,
+            2,
             # +Z face
             4,
-            7,
-            6,
+            6,
+            7,
             4,
-            6,
             5,
+            6,
         ],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@newton/tests/test_speculative_contacts.py` around lines 247 - 314, The
triangle winding in _make_thin_wall_mesh is inverted causing inward normals; fix
by reversing the vertex order for every triangle in the tris array used by
_make_thin_wall_mesh (i.e., swap the winding of each triple so each face's two
triangles have their indices reversed) so that face normals point outward;
update the tris definition in _make_thin_wall_mesh accordingly so
compute_sdf_from_mesh sees the correct inside/outside orientation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant